<?php
/**
 * li₃: the most RAD framework for PHP (http://li3.me)
 *
 * Copyright 2016, Union of RAD. All rights reserved. This source
 * code is distributed under the terms of the BSD 3-Clause License.
 * The full license text can be found in the LICENSE.txt file.
 */

namespace lithium\data;

/**
 * The `Collection` class extends the generic `lithium\util\Collection` class to provide
 * context-specific features for working with sets of data persisted by a backend data store. This
 * is a general abstraction that operates on arbitrary sets of data from either relational or
 * non-relational data stores.
 *
 * Instances of `lithium\data\Collection` or any subclass of it may be serialized. This
 * operation however isn't lossless. The documentation of the `serialize()` method has
 * more information on the limitations.
 *
 * @see lithium\data\Collection::serialize()
 */
abstract class Collection extends \lithium\util\Collection implements \Serializable {

	/**
	 * A reference to this object's parent `Document` object.
	 *
	 * @var object
	 */
	protected $_parent = null;

	/**
	 * If this `Collection` instance has a parent document (see `$_parent`), this value indicates
	 * the key name of the parent document that contains it.
	 *
	 * @see lithium\data\Collection::$_parent
	 * @var string
	 */
	protected $_pathKey = null;

	/**
	 * The fully-namespaced class name of the model object to which this entity set is bound. This
	 * is usually the model that executed the query which created this object.
	 *
	 * @var string
	 */
	protected $_model = null;

	/**
	 * A reference to the query object that originated this entity set; usually an instance of
	 * `lithium\data\model\Query`.
	 *
	 * @see lithium\data\model\Query
	 * @var object
	 */
	protected $_query = null;

	/**
	 * A pointer or resource that is used to load entities from the backend data source that
	 * originated this collection.
	 *
	 * @var resource|object
	 */
	protected $_result = null;

	/**
	 * Indicates whether the current position is valid or not. This overrides the default value of
	 * the parent class.
	 *
	 * @var boolean
	 * @see lithium\util\Collection::valid()
	 */
	protected $_valid = true;

	/**
	 * Contains an array of backend-specific statistics generated by the query that produced this
	 * `Collection` object. These stats are accessible via the `stats()` method.
	 *
	 * @see lithium\data\Collection::stats()
	 * @var array
	 */
	protected $_stats = [];

	/**
	 * Set to `true` when the collection has begun iterating.
	 *
	 * @var boolean
	 */
	protected $_started = false;

	/**
	 * Indicates whether this array was part of a document loaded from a data source, or is part of
	 * a new document, or is in newly-added field of an existing document.
	 *
	 * @var boolean
	 */
	protected $_exists = false;

	/**
	 * If the `Collection` has a schema object assigned (rather than loading one from a model), it
	 * will be assigned here.
	 *
	 * @see lithium\data\Schema
	 * @var lithium\data\Schema
	 */
	protected $_schema = null;

	/**
	 * Hold the "data export" handlers where the keys are fully-namespaced class
	 * names, and the values are closures that take an instance of the class as a
	 * parameter, and return an array or scalar value that the instance represents.
	 *
	 * @see lithium\data\Collection::to()
	 * @var array
	 */
	protected $_handlers = [];

	/**
	 * Holds an array of values that should be processed on initialization.
	 *
	 * @var array
	 */
	protected $_autoConfig = [
		'model', 'result', 'query', 'parent', 'stats', 'pathKey', 'exists', 'schema', 'handlers'
	];

	/**
	 * Constructor.
	 *
	 * @see lithium\data\Collection::$_data
	 * @see lithium\data\Collection::$_model
	 * @param array $config Available configuration options are:
	 *        - `'data'` _array_
	 *        - `'model'` _string|null_
	 * @return void
	 */
	public function __construct(array $config = []) {
		$defaults = ['data' => [], 'model' => null];
		parent::__construct($config + $defaults);
	}

	protected function _init() {
		$data = $this->_config['data'];
		parent::_init();
		$this->set($data);
		foreach (['classes', 'model', 'result', 'query'] as $key) {
			unset($this->_config[$key]);
		}
	}

	/**
	 * Configures protected properties of a `Collection` so that it is parented to `$parent`.
	 *
	 * @param object $parent
	 * @param array $config
	 * @return void
	 */
	public function assignTo($parent, array $config = []) {
		foreach ($config as $key => $val) {
			$this->{'_' . $key} = $val;
		}
		$this->_parent =& $parent;
	}

	/**
	 * Returns the model which this particular collection is based off of.
	 *
	 * @return string The fully qualified model class name.
	 */
	public function model() {
		return $this->_model;
	}

	/**
	 * Returns the object's parent `Document` object.
	 *
	 * @return object
	 */
	public function parent() {
		return $this->_parent;
	}

	/**
	 * A flag indicating whether or not the items of this collection exists.
	 *
	 * @return boolean `True` if exists, `false` otherwise.
	 */
	public function exists() {
		return $this->_exists;
	}

	public function schema($field = null) {
		$schema = null;

		switch (true) {
			case ($this->_schema):
				$schema = $this->_schema;
			break;
			case ($model = $this->_model):
				$schema = $model::schema();
			break;
		}
		if ($schema) {
			return $field ? $schema->fields($field) : $schema;
		}
	}

	/**
	 * Allows several items to be assigned at once.
	 *
	 * For example:
	 * ```
	 * $collection->set([
	 *	42 => ['title' => 'Lorem Ipsum'],
	 *	43 => ['title' => 'Dolor Amet']
	 * ]);
	 * ```
	 *
	 * @param arary $values An associative array of fields and values to assign to the `Collection`.
	 * @return void
	 */
	public function set($values) {
		foreach ($values as $key => $val) {
			$this[$key] = $val;
		}
	}

	/**
	 * Returns a boolean indicating whether an offset exists for the
	 * current `Collection`.
	 *
	 * @param string $offset String or integer indicating the offset or
	 *        index of an entity in the set.
	 * @return boolean Result.
	 */
	public function offsetExists($offset) {
		$this->offsetGet($offset);
		return array_key_exists($offset, $this->_data);
	}

	/**
	 * Gets an `Entity` object using PHP's array syntax, i.e. `$documents[3]` or `$records[5]`.
	 *
	 * @param mixed $offset The offset.
	 * @return mixed Returns an `Entity` object if exists otherwise returns `null`.
	 */
	public function offsetGet($offset) {
		while (!array_key_exists($offset, $this->_data) && $this->_populate()) {}

		if (array_key_exists($offset, $this->_data)) {
			return $this->_data[$offset];
		}
		return null;
	}

	/**
	 * Adds the specified object to the `Collection` instance, and assigns associated metadata to
	 * the added object.
	 *
	 * @param string $offset The offset to assign the value to.
	 * @param mixed $data The entity object to add.
	 * @return mixed Returns the set `Entity` object.
	 */
	public function offsetSet($offset, $data) {
		$this->offsetGet($offset);
		return $this->_set($data, $offset);
	}

	/**
	 * Unsets an offset.
	 *
	 * @param integer $offset The offset to unset.
	 */
	public function offsetUnset($offset) {
		$this->offsetGet($offset);
		prev($this->_data);
		if (key($this->_data) === null) {
			$this->rewind();
		}
		unset($this->_data[$offset]);
	}

	/**
	 * Rewinds the collection to the beginning.
	 */
	public function rewind() {
		$this->_started = true;
		reset($this->_data);
		$this->_valid = !empty($this->_data) || $this->_populate() !== null;
		return current($this->_data);
	}

	/**
	 * Returns the currently pointed to record's unique key.
	 *
	 * @param boolean $full If true, returns the complete key.
	 * @return mixed
	 */
	public function key($full = false) {
		if ($this->_started === false) {
			$this->current();
		}
		if ($this->_valid) {
			$key = key($this->_data);
			return (is_array($key) && !$full) ? reset($key) : $key;
		}
		return null;
	}

	/**
	 * Returns the item keys.
	 *
	 * @return array The keys of the items.
	 */
	public function keys() {
		$this->offsetGet(null);
		return parent::keys();
	}

	/**
	 * Returns the currently pointed to record in the set.
	 *
	 * @return object|boolean An instance of `Record` or `false` if there is no current valid one.
	 */
	public function current() {
		if (!$this->_started) {
			$this->rewind();
		}
		if (!$this->_valid) {
			return false;
		}
		return current($this->_data);
	}

	/**
	 * Returns the next document in the set, and advances the object's internal pointer. If the end
	 * of the set is reached, a new document will be fetched from the data source connection handle
	 * If no more documents can be fetched, returns `null`.
	 *
	 * @return mixed Returns the next document in the set, or `false`, if no more documents are
	 *         available.
	 */
	public function next() {
		if (!$this->_started) {
			$this->rewind();
		}
		next($this->_data);
		$this->_valid = key($this->_data) !== null;

		if (!$this->_valid) {
			$this->_valid = $this->_populate() !== null;
		}
		return current($this->_data);
	}

	/**
	 * Checks if current position is valid.
	 *
	 * @return boolean `true` if valid, `false` otherwise.
	 */
	public function valid() {
		if (!$this->_started) {
			$this->rewind();
		}
		return $this->_valid;
	}

	/**
	 * Overrides parent `find()` implementation to enable key/value-based filtering of entity
	 * objects contained in this collection.
	 *
	 * @param mixed $filter Callback to use for filtering, or array of key/value pairs which entity
	 *        properties will be matched against.
	 * @param array $options Options to modify the behavior of this method. See the documentation
	 *        for the `$options` parameter of `lithium\util\Collection::find()`.
	 * @return mixed The filtered items. Will be an array unless `'collect'` is defined in the
	 *         `$options` argument, then an instance of this class will be returned.
	 */
	public function find($filter, array $options = []) {
		$this->offsetGet(null);
		if (is_array($filter)) {
			$filter = $this->_filterFromArray($filter);
		}
		return parent::find($filter, $options);
	}

	/**
	 * Overrides parent `first()` implementation to enable key/value-based filtering.
	 *
	 * @param mixed $filter In addition to a callback (see parent), can also be an array where the
	 *              keys and values must match the property values of the objects being inspected.
	 * @return object Returns the first object found matching the filter criteria.
	 */
	public function first($filter = null) {
		return parent::first(is_array($filter) ? $this->_filterFromArray($filter) : $filter);
	}

	/**
	 * Creates a filter based on an array of key/value pairs that must match the items in a
	 * `Collection`.
	 *
	 * @param array $filter An array of key/value pairs used to filter `Collection` items.
	 * @return \Closure Returns a closure that wraps the array and attempts to match each value
	 *         against `Collection` item properties.
	 */
	protected function _filterFromArray(array $filter) {
		return function($item) use ($filter) {
			foreach ($filter as $key => $val) {
				if ($item->{$key} != $val) {
					return false;
				}
			}
			return true;
		};
	}

	/**
	 * Returns meta information for this `Collection`.
	 *
	 * @return array
	 */
	public function meta() {
		return ['model' => $this->_model];
	}

	/**
	 * Applies a callback to all data in the collection.
	 *
	 * Overridden to load any data that has not yet been loaded.
	 *
	 * @param callback $filter The filter to apply.
	 * @return object This collection instance.
	 */
	public function each($filter) {
		$this->offsetGet(null);
		return parent::each($filter);
	}

	/**
	 * Applies a callback to a copy of all data in the collection
	 * and returns the result.
	 *
	 * Overriden to load any data that has not yet been loaded.
	 *
	 * @param callback $filter The filter to apply.
	 * @param array $options The available options are:
	 *        - `'collect'`: If `true`, the results will be returned wrapped
	 *        in a new `Collection` object or subclass.
	 * @return object The filtered data.
	 */
	public function map($filter, array $options = []) {
		$defaults = ['collect' => true];
		$options += $defaults;

		$this->offsetGet(null);
		$data = parent::map($filter, $options);

		if ($options['collect']) {
			foreach (['_model', '_schema', '_pathKey'] as $key) {
				$data->{$key} = $this->{$key};
			}
		}
		return $data;
	}

	/**
	 * Reduce, or fold, a collection down to a single value
	 *
	 * Overridden to load any data that has not yet been loaded.
	 *
	 * @param callback $filter The filter to apply.
	 * @param mixed $initial Initial value
	 * @return mixed A single reduced value
	 */
	public function reduce($filter, $initial = false) {
		if (!$this->closed()) {
			while ($this->next()) {}
		}
		return parent::reduce($filter, $initial);
	}

	/**
	 * Sorts the objects in the collection, useful in situations where
	 * you are already using the underlying datastore to sort results.
	 *
	 * Overriden to load any data that has not yet been loaded.
	 *
	 * @see lithium\util\Collection::sort()
	 * @param string|callable $field The field to sort the data on, can also be a callback
	 *        to a custom sort function.
	 * @param array $options Reserved for future use.
	 * @return lithium\data\Collection Returns itself.
	 */
	public function sort($field = 'id', array $options = []) {
		$this->offsetGet(null);

		if (is_string($field)) {
			$sorter = function ($a, $b) use ($field) {
				if (is_array($a)) {
					$a = (object) $a;
				}
				if (is_array($b)) {
					$b = (object) $b;
				}
				return strcmp($a->$field, $b->$field);
			};
		} elseif (is_callable($field)) {
			$sorter = $field;
		} else {
			return $this;
		}
		return parent::sort($sorter, $options);
	}

	/**
	 * Converts the current state of the data structure to an array.
	 *
	 * @return array Returns the array value of the data in this `Collection`.
	 */
	public function data() {
		return $this->to('array', ['indexed' => null]);
	}

	/**
	 * Converts a `Collection` object to another type of object, or a simple type such as an array.
	 * The supported values of `$format` depend on the format handlers registered in the static
	 * property `Collection::$_formats`. The `Collection` class comes with built-in support for
	 * array conversion, but other formats may be registered.
	 *
	 * Once the appropriate handlers are registered, a `Collection` instance can be converted into
	 * any handler-supported format, i.e.:
	 * ```
	 * $collection->to('json'); // returns a JSON string
	 * $collection->to('xml'); // returns an XML string
	 * ```
	 *
	 *  _Please note that Lithium does not ship with a default XML handler, but one can be
	 * configured easily._
	 *
	 * @see lithium\util\Collection::toArray()
	 * @see lithium\util\Collection::formats()
	 * @see lithium\util\Collection::$_formats
	 * @param string $format By default the only supported value is `'array'`. However, additional
	 *        format handlers can be registered using the `formats()` method.
	 * @param array $options Options for converting this collection:
	 *        - `'internal'` _boolean_: Indicates whether the current internal representation of the
	 *          collection should be exported. Defaults to `false`, which uses the standard iterator
	 *          interfaces. This is useful for exporting record sets, where records are lazy-loaded,
	 *          and the collection must be iterated in order to fetch all objects.
	 *        - `'indexed'` _boolean|null_: Allows to control how converted data is keyed. When set
	 *          to `true` will force indexed conversion of the collection (the default) even if the
	 *          collection has a parent. When `false` will convert without indexing. Provide `null`
	 *          as a value to this option to only index when the collection has no parent.
	 * @return mixed The object converted to the value specified in `$format`; usually an array or
	 *         string.
	 */
	public function to($format, array $options = []) {
		$defaults = ['internal' => false, 'indexed' => true, 'handlers' => []];
		$options += $defaults;

		$options['handlers'] += $this->_handlers;
		$this->offsetGet(null);

		$index = $options['indexed'] || ($options['indexed'] === null && $this->_parent === null);
		if (!$index) {
			$data = array_values($this->_data);
		} else {
			$data = $options['internal'] ? $this->_data : $this;
		}
		return $this->_to($format, $data, $options);
	}

	/**
	 * Return's the pointer or resource that is used to load entities from the backend
	 * data source that originated this collection. This is useful in many cases for
	 * additional methods related to debugging queries.
	 *
	 * @return object The pointer or resource from the data source
	 */
	public function result() {
		return $this->_result;
	}

	/**
	 * Gets the stat or stats associated with this `Collection`.
	 *
	 * @param string $name Stat name.
	 * @return mixed Single stat if `$name` supplied, else all stats for this
	 *         `Collection`.
	 */
	public function stats($name = null) {
		if ($name) {
			return isset($this->_stats[$name]) ? $this->_stats[$name] : null;
		}
		return $this->_stats;
	}

	/**
	 * Executes when the associated result resource pointer reaches the end of its data set. The
	 * resource is freed by the connection, and the reference to the connection is unlinked.
	 */
	public function close() {
		if (!empty($this->_result)) {
			unset($this->_result);
			$this->_result = null;
		}
	}

	/**
	 * Checks to see if this entity has already fetched all available entities and freed the
	 * associated result resource.
	 *
	 * @return boolean Returns true if all entities are loaded and the database resources have been
	 *         freed, otherwise returns false.
	 */
	public function closed() {
		return empty($this->_result);
	}

	/**
	 * Destructor. Ensures that the data set's connection is closed when the object is destroyed.
	 *
	 * @return void
	 */
	public function __destruct() {
		$this->close();
	}

	/**
	 * Prepares, enables and executes serialization of the object.
	 *
	 * Note: because of the limitations outlined below custom
	 * handlers are ignored with serialized objects.
	 *
	 * Pulls all results to entirely populate `_data` and closes the object
	 * freeing the associated result resource. This allows for skipping
	 * the `_result` property which may hold unserializable `PDOStatement`s.
	 *
	 * Properties that hold anonymous functions are also skipped. Some of these
	 * can almost be reconstructed (`_handlers`).
	 *
	 * @return string Serialized properties of the object.
	 */
	public function serialize() {
		$this->offsetGet(null);
		static::__destruct();

		$vars = get_object_vars($this);
		unset($vars['_result']);
		unset($vars['_handlers']);

		return serialize($vars);
	}

	/**
	 * Prepares, enables and executes unserialization of the object.
	 *
	 * Restores state of the object including pulled results. Tries
	 * to restore `_handlers` by calling into `_init()`.
	 *
	 * @param string $data Serialized properties of the object.
	 * @return void
	 */
	public function unserialize($data) {
		$vars = unserialize($data);
		parent::_init();

		foreach ($vars as $key => $value) {
			$this->{$key} = $value;
		}
	}

	/**
	 * A method to be implemented by concrete `Collection` classes which, provided a reference to a
	 * backend data source, and a resource representing a query result cursor, fetches new result
	 * data and wraps it in the appropriate object type, which is added into the `Collection` and
	 * returned.
	 *
	 * @return mixed Returns the next `Record`, `Document` object or other `Entity` object if
	 *         exists. Returns `null` otherwise.
	 */
	abstract protected function _populate();

	/**
	 * A method to be implemented by concrete `Collection` classes which sets data to a specified
	 * offset and wraps all data array in its appropriate object type.
	 *
	 * @see lithium\data\Collection::_populate()
	 * @see lithium\data\Collection::offsetSet()
	 * @param mixed $data An array or an `Entity` object to set.
	 * @param mixed $offset The offset. If offset is `null` data is simply appended to the set.
	 * @param array $options Any additional options to pass to the `Entity`'s constructor.
	 * @return object Returns the inserted `Record`, `Document` object or other `Entity` object.
	 */
	abstract protected function _set($data = null, $offset = null, $options = []);
}

?>